**Final Project** Student name: Chia-Wei Lee Netid: f00813s (#) Inspirations When I think about "entangled", I want to create a scene where objects are actually entagled by lights. So I choose to create a scene where many objects are covered by the lights going through a window blinds. Where the lights sort of "entangle" with the objects, casting lines of shadow on the objects. I want to see different light effects on golden and wooden objects. For my inspirations, it comes from video games 'Metro Exodus' and 'Dishonored 2', where the environment art gives a lot of antique + wood feeling. Overall, the theme I am going for is gold materials, wood materials, old and antique style.
(#) Feature 1: Mitchell–Netravali Filter (1 pts)
I choose to implement Mitchell–Netravali filter because my pc is not very powerful, therefore I want to use a better reconstruction filter to reduce noise at low sample counts.
float MitchellFilter::mitchell1d(float x) const
{
// src/filter.cpp
x = abs(x);
// Scale x into [-2,2] domain expected by the kernel
// (support is [-radius, radius])
const float extent = 2.f;
if (extent > 0.f)
x = (x / m_radius.x) * extent;
const float B = m_B;
const float C = m_C;
if (x < 1.f)
{
return ((12 - 9 * B - 6 * C) * x * x * x + (-18 + 12 * B + 6 * C) * x * x +
(6 - 2 * B)) *
(1.f / 6.f);
}
else if (x < 2.f)
{
return ((-B - 6 * C) * x * x * x + (6 * B + 30 * C) * x * x + (-12 * B - 48 * C) * x +
(8 * B + 24 * C)) *
(1.f / 6.f);
}
else
{
return 0.f;
}
}
The system fallback to Box filter when no filter is specified in the json file.
(#) Feature 2: Additional Procedural Texture: Mugsgrave (2 pts)
The Mugsgrave noise is generated by combining multiple octaves of Perlin noise. This
creates natural textures such as marble or wood grain.
Apply layers of Perlin noise with increasing frequency and decreasing amplitude.
// src/textures/mugsgrave.cpp
for (int i = 0; i < octaves; ++i)
{
float n = perlin_noise(p * freq); // [-1,1]
n = 0.5f * (n + 1.0f); // [0,1]
sum += amp * n;
freq *= lacunarity;
amp *= std::pow(lacunarity, -dimension);
}
There are 2 modes, the default FBM mode simply add all layers together.
The ridged mode inverts the noise to create sharp ridges, cracked texture.
float n = perlin_noise(p * freq); // [-1,1]
n = offset - std::abs(n); // invert valleys
n = std::max(n, 0.0f);
n *= n; // sharpen ridges
n *= weight;
sum += n * amp;
(#) Feature 3: Environment Map Emitter (2 pts)
// src/surfaces/environment.cpp
for (int y = 0; y < height; ++y)
{
std::vector<float> cols(width, 0.f);
float theta = (y + 0.5f) * float(M_PI) / float(height);
float sin_theta = std::sin(theta);
for (int x = 0; x < width; ++x)
{
float lum = luminance(img(x, y));
cols[x] = lum * sin_theta; // luminance * sinθ
}
col_cdfs[y].build(cols);
row_weights[y] = col_cdfs[y].norm();
}
row_cdf.build(row_weights);
Based on the row picked, I sample a random column with the same weight and turn (row, col) to spherical coordinates to get the direction.
And compute the solid angle PDF accordingly:
pdf = (row_pdf*col_pdf)*width*height / (2π² sinθ)
The emitter is given an infinte bouding box and always return false on intersection, loaded only into m_emitters and not in BVH.
This esstentially makes it an infintly far away light source.
(#) Feature 4: Simple Extra BSDF (GGX Microfacet Model ) (2 pts)
">
Vec3f sample_visible_normal(const Vec3f &wi, float ax, float ay, const Vec2f &u)
{
// VNDF src/materials/ggx.cpp
// 1. Stretch wi
Vec3f wi_stretched = la::normalize(Vec3f(ax * wi.x, ay * wi.y, wi.z));
// 2. Build orthonormal basis
float lensq = wi_stretched.x * wi_stretched.x + wi_stretched.y * wi_stretched.y;
Vec3f T1 = lensq > 0.f ? Vec3f(-wi_stretched.y, wi_stretched.x, 0.f) / std::sqrt(lensq) : Vec3f(1.f, 0.f, 0.f);
Vec3f T2 = la::cross(wi_stretched, T1);
// 3. Sample P22_11
float r = std::sqrt(u.x);
float phi = 2.f * M_PI * u.y;
float t1 = r * std::cos(phi);
float t2 = r * std::sin(phi);
float s = 0.5f * (1.f + wi_stretched.z);
t2 = (1.f - s) * std::sqrt(std::max(0.f, 1.f - t1 * t1)) + s * t2;
// 4. Reproject
Vec3f Nh = t1 * T1 + t2 * T2 + std::sqrt(std::max(0.f, 1.f - t1 * t1 - t2 * t2)) * wi_stretched;
// 5. Unstretch
Vec3f h = la::normalize(Vec3f(ax * Nh.x, ay * Nh.y, std::max(0.f, Nh.z)));
return h;
}
VNDF generates microfacet normal more efficiently. VNDF is computed first to generate the direction sample,
then PDF() and eval() will be called to compute the GGX PDF and BRDF.
float ggx_D_aniso(const Vec3f &m, float ax, float ay)
{
// The D term
if (m.z <= 0.f)
return 0.f;
float tx = m.x / ax;
float ty = m.y / ay;
float denom = tx * tx + ty * ty + m.z * m.z;
denom = denom * denom;
return 1.f / (M_PI * ax * ay * denom);
}
The D term defines the distribution of microfacet normals. In my implementation I did anisotropic materail which use 2 roughness variables
and gives better flexibility.
float ggx_G_aniso(const Vec3f &wi, const Vec3f &wo, float ax, float ay)
{
float lambda_i = ggx_lambda_aniso(wi, ax, ay);
float lambda_o = ggx_lambda_aniso(wo, ax, ay);
float denom = 1.f + lambda_i + lambda_o;
return denom > 0.f ? 1.f / denom : 0.f;
}
The geometry term computes self-shadowing and masking.
Color3f f = F * (D * G) / std::max(4.f * wi.z * wo.z, 1e-7f);
return f * wo.z; // return f * cos(theta_o)
Combine D, G and Fresnel term we get the GGX BRDF value.
(#) Feature 5: Homogeneous Participating Media (4 pts)
Comparison between renderings with and without homogeneous participating media (fog).
Both images are rendered with Mitchell–Netravali filter at 128 spp.
bool sample_free_flight(const Ray3f &ray, int channel, Sampler &sampler, HitRecord &hit, Color3f &f,
Color3f &p) const override
// src/media/homogeneous.cpp
{
// sample a free-flight distance with majorant
float dist = -std::log(1.f - sampler.next1f()) / m_total[channel];
// the sampled distance is along the ray, so the new potential hit point is
hit.t = ray.mint + dist;
if (hit.t < ray.maxt)
{
// inside medium, so this is a potential collision
hit.p = ray(hit.t);
// what's the density at this point?
Color3f real_ext = m_total * m_real_fraction;
// update throughput and pdf for real scattering event
f *= exp(-m_total * dist) * real_ext;
p *= exp(-m_total * dist) * m_total;
// f / p = the possibility of a real scattering event (computed in vol_path_tracer.cpp)
return true;
}
else
{
...
return false;
}
}
If distance is within [mint, maxt], there is a potential medium interaction and the function returns true.
Else if distance is out of range:
1. adjust hit.t to maxt (rays exits medium),
2. times real fraction with the transmiitance in this section,
3. return false.
In vol_path_tracer.cpp, I integrated the MIS code with the medium sampling.
Everytime the sample_free_glight() from homogeneous.cpp returns true, it signals a medium interaction. The integrator then samples a new direction by:
1. timeing throughput with real fraction.
2. then conduct a new NEE with phase function.
if (medium)
// src/integrators/vol_path_tracer.cpp
{
Color3f f(1.f), p(1.f);
int channel = std::min(int(sampler->next1f() * 3), 2);
if (medium->sample_free_flight(Ray3f(ray.o, ray.d, ray.mint, found_surface ? surf_hit.t : ray.maxt),
channel, *sampler, med_hit, f, p))
{
if (la::maxelem(p) > 0.f)
beta *= f / la::maxelem(p);
else
break;
scattered_in_medium = true;
}
else if (la::maxelem(p) > 0.f)
beta *= f / la::maxelem(p);
else
break;
}
(##) Scatter within medias:
1. If there are emitters in the media, sample from the medium point to the light source. Times total transmittance and do MIS with phase function + light source PDF.
- Then I sample new direction based on phase function. (If there is not emitter in the medium, sample directly from phase function.)
// Sample scattered direction in medium.
//src/integrators/vol_path_tracer.cpp
if (scattered_in_medium)
{
specular_bounce = false;
// NEE for direct lighting in medium
if (scene->m_emitters)
{
EmitterRecord er(ray, med_hit.p);
Color3f Le_over_pdf = scene->m_emitters->sample(er, sampler->next2f());
if (er.pdf > 0.f && isfinite(er.pdf))
{
Ray3f shadow_ray(med_hit.p, er.wi, Ray3f::epsilon, er.hit.t - Ray3f::epsilon);
shadow_ray.medium = medium;
HitRecord occ;
bool visible = !scene->intersect(shadow_ray, occ) || occ.t + Ray3f::epsilon >= er.hit.t;
if (visible)
{
Color3f tr = medium->total_transmittance(shadow_ray, *sampler);
if (la::maxelem(tr) > 0.f)
{
float phase_pdf = medium->phase_function->pdf(ray, er.wi, med_hit);
float mis_weight = er.pdf / (er.pdf + phase_pdf);
L += beta * medium->phase_function->eval(ray, er.wi, med_hit) * tr * Le_over_pdf * mis_weight;
}
}
}
}
}
2. If it didnt't scatter and didn't hit any surface:
Use light source PDF + Phase function for MIS if the last intersection is not specular, if it is specular then just add background color.
3. If it didn't scatter but hit a surface:
- For emitter surface: check if the previous bounce is specular or initialized ray, add them directly into L.
- For non-emitter surface: do MIS sample on new direction based on BSDF + light source PDF.
// src/integrators/vol_path_tracer.cpp
if (surf_hit.mat->is_emissive()) {
if (specular_bounce) {
L += beta * surf_hit.mat->emitted(ray, surf_hit);
} else {
float light_pdf = scene->m_emitters->pdf(prev_hit.p, ray.d);
float bsdf_pdf =
prev_medium ? prev_medium->phase_function->pdf(prev_ray, ray.d, prev_hit) :
(prev_hit.mat ? prev_hit.mat->pdf(prev_ray, ray.d, prev_hit) : 0.f);
float mis_weight = bsdf_pdf / (bsdf_pdf + light_pdf);
L += beta * surf_hit.mat->emitted(ray, surf_hit) * mis_weight;
}
}
(#) Feature 6: Rough Conductor Material (2 pts)
">
Color3f fresnel_conductor(float cos_theta_i, const Color3f &eta, const Color3f &k)
{
cos_theta_i = std::abs(cos_theta_i);
float cos2 = cos_theta_i * cos_theta_i;
float sin2 = std::max(0.f, 1.f - cos2);
Color3f eta2 = eta * eta;
Color3f k2 = k * k;
Color3f t0 = eta2 - k2 - Color3f(sin2);
Color3f a2b2 = Color3f{std::sqrt(t0.x * t0.x + 4.f * eta2.x * k2.x),
std::sqrt(t0.y * t0.y + 4.f * eta2.y * k2.y),
std::sqrt(t0.z * t0.z + 4.f * eta2.z * k2.z)};
Color3f t1 = a2b2 + Color3f(cos2);
Color3f a = Color3f{std::sqrt(0.5f * (a2b2.x + t0.x)),
std::sqrt(0.5f * (a2b2.y + t0.y)),
std::sqrt(0.5f * (a2b2.z + t0.z))};
Color3f t2 = 2.f * cos_theta_i * a;
Color3f Rs = (t1 - t2) / (t1 + t2);
Color3f t3 = Color3f(cos2) * a2b2 + Color3f(sin2 * sin2);
Color3f t4 = t2 * Color3f(sin2);
Color3f Rp = Rs * ((t3 - t4) / (t3 + t4));
Color3f F = 0.5f * (Rp + Rs);
return clamp01(F);
}
(#) Feature 7~9: Three additional lighting sources (1*3 pts)
I also added 3 light sources to see how they their effect would be if going through window blinds.
I ultimately decided to use sun (directional) light.
">
I didn't really get the desired "window blinds shadow" result I wanted to. I think the main reason is thet
it is very difficult to sample lights properly in a dark room where the only light source are from the window.
This image without texture applied illustrates the effect I want:
A potential solution to this would be to take a lot more samples (>1024 spp). Or I will have to try a more
efficient light sampling stratagies such as bi-directional path tracing.
(#) Encountered Problems:
The hardest part in my opinion is setting up blender scene. It is incredibly difficult to adjust these cameras and angles,
and dealing with missing objects and textures is very time consuming. The Homogeneous meida is also very challanging to implement properly.
(#) External Libraries (project/report)
For this task, I mostly used the libraries provided in the DARTS repo.
(#) Reference
Inspiration image 1:
https://andrzejdybowski.com/portfolio/metro-exodus/#
Inspiration image 2:
https://80.lv/articles/environment-storytelling-in-dishonored-2